feat(core): add three sandbox backends and move SandboxFileNotFoundError onto the contract - #12
Conversation
`@pleaseai/core/sandbox/local` implements the sandbox contract by running real
processes on the host, as the sibling of the Docker backend and its opposite
trade: no daemon, no image pull, no container — and no isolation.
The contract's durability requirement is what shapes it. `getProcess(id)` and
`logs({ replay: true })` are read after a process exits, frequently by a host
process that never started it, so commands are journalled to disk rather than
held as child handles. Two pieces of the Docker wrapper could not come along:
macOS ships no `setsid` and its `tail` has no `--pid`. Measured replacements —
`Bun.spawn({ detached: true })` already makes the wrapper a process-group
leader, and a followed read polls file offsets instead of shelling out.
The wrapper is also a constant: the journal directory, the timeout and the argv
travel beside it as positional parameters, so no quoting step stands between a
caller's `SandboxCommand` and `execve`.
Two policy decisions, both stated where they are made:
- the environment is allowlisted down from `process.env` (flue's
`DEFAULT_LOCAL_ENV_ALLOWLIST`), so the agent's bash tool does not inherit
every credential the developer is carrying;
- `IS_SANDBOX` is deliberately *not* declared, the inverse of the Docker
backend. There the claim is true; here it would be false, and the root check
it defeats is the last thing between a bypassed permission prompt and the
developer's own home directory.
A sandbox id resolves to a backend-owned directory (`work/` + `journal/`) under
a caller-named root, which is what makes `destroy()` safe to write at all — it
only ever deletes a path this backend derived, never one a caller handed in.
Both backends declared a class of the same name, which is the one shape that satisfies every behavioural test and still fails the thing the class is for: `instanceof` answers no across them, so a caller handed either backend could not write one `catch`. That is the same argument the contract already makes for `SandboxWaitTimeoutError` — a shared identity to test against, because a string-matched message is not one — so the class belongs where those live. Both subpaths keep exporting the symbol, now sourced from the contract, so no caller's import changes. `../harness/files.ts` is untouched: it decides absence by re-checking `exists` rather than by matching an error type, which is what lets it answer `null` for a path that vanished between the two calls. The stale "the one runtime value this package exports" note on `SandboxWaitTimeoutError` is corrected — there were already two. Pinned by `test/sandbox/contract/errors.test.ts`, which compares the exports themselves rather than any behaviour: a backend that re-declared its own class would pass every other test in the suite.
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
All three were verified against a live sandbox before and after, and each is now pinned by a test that was confirmed to fail on the unfixed code. **A failed spawn left a permanent phantom.** The abandon-marker guard wrapped only the start confirmation, not the spawn. `Bun.spawn` throws outright for a `cwd` the host does not have, and `prepareJournal` has already written the journal by then — so every later `listProcesses()` reported that id as a process stuck in the `error` state, which is exactly what the marker exists to prevent. **Hardcoded signal numbers were Linux's.** `SIGUSR1` is 10 on Linux and 30 on macOS, so the wrapper recorded 10 while the shell exited with 158, the two were found to disagree, and a signalled process was reported as having merely returned a large code. In a file whose whole preamble is about macOS portability. The numbers now come from the host's own table, and `SIGTERM` / `SIGKILL` with them, so the record and `128 + n` stay locked together. **The timeout watchdog leaked its `sleep`.** Standing the watchdog down killed the subshell but not the `sleep` it had forked, which then ran out the full budget — hour-scale, for the timeout a long turn is given — after the command had already exited. The nap is now the watchdog's own child, reaped by a TERM disposition. Two further findings were reported and are fixed here as documentation rather than code, because in both cases the code was right and a comment was not: `readFile`'s note claimed a rejection *type* the contract only says a backend SHOULD use, which contradicted `harness/files.ts`'s reason for re-checking `exists`; and `MAX_CHUNK_BYTES` read as though it bounded the queue, when the pump enqueues without consulting `desiredSize`. One finding the review left as a design call is fixed too, because it has a repair that needs no new contract state: discovery is now gated on the wrapper having actually launched — a pid, or an exit — rather than on the journal directory existing. `prepareJournal` writes `meta` from the host before the spawn, so a `listProcesses()` racing an in-flight `exec()` saw a healthy process as failed. Not yet listed is the honest answer for that window; `exec` has not returned a handle for it either. Still open, and deliberately not repaired here: the log stream applies no backpressure. Fixing it means driving the pump from `pull`, which changes when it runs, and that is a bigger change than this commit should carry.
There was a problem hiding this comment.
All reported issues were addressed across 26 files
Architecture diagram
sequenceDiagram
participant Caller as Sandbox Caller
participant Provider as Local Sandbox Provider
participant Session as Local Session
participant Root as Sandbox Root
participant Journal as Journal (Disk)
participant Wrapper as Wrapper Script
participant Command as Sandboxed Command
participant Files as File System API
Note over Caller,Files: Local Host-Process Sandbox Backend
Caller->>Provider: createLocalSandbox({ root, env })
Provider->>Provider: resolveBaseEnv(env)
Note over Provider: Allowlist process.env<br/>No IS_SANDBOX declared
Caller->>Provider: session(sandboxId)
Provider->>Root: createSandboxRoot(sandboxId, root)
Root->>Root: Derive sanitized dirName + digest
Provider-->>Caller: SandboxSession
Caller->>Session: exec(command, { timeout })
Session->>Root: ready()
Root->>Journal: mkdir(work/, journal/)
Session->>Journal: prepareJournal(paths, meta)
Note over Journal: Write meta, empty out/err files
Session->>Wrapper: Bun.spawn({ detached: true })
Note over Wrapper: POSIX shell wrapper<br/>Leads its own process group
alt Spawn fails (bad cwd, etc.)
Session->>Journal: Write abandon marker
Session-->>Caller: Throw start error
else Spawn succeeds
Wrapper->>Journal: Write pid
Wrapper->>Wrapper: Clear signal traps in child
Wrapper->>Command: exec "$@" (argv as-is, no quoting)
Command->>Journal: stdout/stderr redirected to files
end
alt Timeout configured
Wrapper->>Wrapper: Start watchdog (subshell + sleep)
Note over Wrapper: Watchdog fires on budget expiry
Wrapper->>Journal: Touch timeout marker
Wrapper->>Wrapper: kill -TERM -$wrapper (process group)
Wrapper->>Journal: Signal handler writes signal number
opt Escalation needed (trap '' TERM)
Wrapper->>Wrapper: Escalator sleep 3s
Wrapper->>Journal: Write SIGKILL exit code
Wrapper->>Wrapper: kill -9 -$wrapper
end
end
Wrapper->>Journal: Write exit code
Wrapper-->>Session: Process ends
Session-->>Caller: SandboxProcessHandle
Caller->>Session: getProcess(processId)
Session->>Root: peek() (no creation)
Session->>Journal: Read journal state
alt Abandon marker present
Session-->>Caller: null (process never launched)
else No pid/exit recorded
Session-->>Caller: null (not yet started)
else Valid journal
Session-->>Caller: Reconstructed handle
end
Caller->>Session: waitForExit()
Session->>Journal: Poll exit file
alt Process still running
Session->>Journal: Continue polling
alt Caller timeout/abort
Session-->>Caller: SandboxWaitTimeoutError
end
else Process gone, no exit record
Session-->>Caller: SandboxNoExitRecordError
else Exit recorded
Session-->>Caller: ProcessExit
end
Caller->>Session: logs({ replay: true, follow })
Session->>Journal: Read out/err file offsets
loop While process alive or new data
Session->>Journal: Poll file sizes (offset-based)
alt Only checkpoint / not alive
Session-->>Caller: Terminal event (exit/error)
else New data available
Session-->>Caller: stdout/stderr events (64KB chunks)
end
end
Caller->>Session: kill()
alt Process alive
Session->>Session: process.kill(-pid, signal)
Note over Session: Targets whole process group
Session->>Journal: Poll for exit record
else Already exited
Session-->>Caller: No-op (pid is historical)
end
Caller->>Session: destroy()
Session->>Root: remove()
Root->>Files: rm(root/dirName, recursive, force)
Root-->>Session: Deleted
Provider->>Provider: Clear cached root (if identity matches)
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
The third backend, and the first that runs no process at all: `just-bash` interprets commands over an in-memory filesystem, so this one needs no daemon, no image and no host process. It is reached at `@pleaseai/core/sandbox/just-bash`, with `just-bash` as an optional peer dependency imported dynamically — a caller on the docker or local backend never resolves it. Four limits are structural rather than gaps, and each has a test that pins it: - no real binaries — `git --version` exits 127; - no ports — `portEndpoint` raises `JustBashPortsUnavailableError` rather than answering a URL that would dial the host; - no live output — the interpreter coalesces a command's output into one message per stream and delivers it on completion; - no bytes that are not valid UTF-8 — the filesystem stores text, measured: four bytes written as base64 read back as eight, so a write that could only be stored corrupted raises `JustBashBinaryUnsupportedError` instead. Two vendor behaviours needed accommodating. `runCommand`'s `env` is accepted and never applied, so per-exec env goes through a constant wrapper script with names, values and argv all passed as positional parameters — no interpolation, the same discipline as the local backend's journal wrapper. And defence-in-depth patches `Module._resolveFilename`, which fails under Bun and kills the first command rather than the constructor, so it defaults to the runtime's support and stays caller-overridable. `SandboxWaitTimeoutError` and `timedOut` come from a timer this backend owns: the interpreter reports every cancellation as exit code 124, so the exit code cannot tell a caller's kill from an expired budget.
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
|
Warning Review the following alerts detected in dependencies. According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.
|
There was a problem hiding this comment.
All reported issues were addressed across 19 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Re-trigger cubic
The fourth backend, and the only one whose isolation is a hypervisor rather
than a namespace or an interpreter: each sandbox is a real kernel booted from
an OCI image, so a guest escape is not a host compromise. Reached at
`@pleaseai/core/sandbox/microsandbox`, with `microsandbox` as an optional peer
dependency imported dynamically.
**Verification status, stated plainly rather than implied.** `microsandbox`
ships no native addon for `darwin-x64`, which is the host this was written on —
`import('microsandbox')` there throws `unsupported platform darwin-x64`. So the
behavioural suite has never been observed to pass: it gates on
`isMicrosandboxAvailable()` and skips, the same shape the Docker suite uses for
an unreachable daemon. Comments in the backend mark what is a decision versus
what the vendor documents; none reports a measurement that was not taken.
What *is* verified everywhere is the part most likely to rot. The vendor's types
are copied structurally so that `microsandbox` stays out of this package's
public `.d.ts`, and `vendor-shape.test.ts` asserts — through `tsc` — that the
vendor's own declarations still satisfy those copies. It earned its place
immediately by rejecting two wrong models of the exec-options builder: a plain
subset interface fails because a callback parameter is checked contravariantly,
and a generic method fails because a concrete vendor signature cannot satisfy a
universally quantified one. The builder is a type parameter on the interface,
which is the one shape that holds.
The process journal is `../docker/journal.ts`, imported rather than copied. That
module contains no Docker — it builds the POSIX shell every guest-side backend
needs to make a process outlive the call that started it — and a second copy of
two hundred lines of signal-handling shell would drift. Reading it differs,
which is why `process-state.ts` is this backend's own: the five shell-answerable
facts come from one script per poll, while `meta` is read through the vendor's
filesystem channel, where caller-supplied argv needs no separator to survive.
Two decisions worth naming. The vendor's `ExecHandle` is deliberately not held
by a process handle: it would answer `wait()` and `kill()` directly, but only
for the host process that started the command, which is exactly the case the
contract's durability requirement excludes. And `portEndpoint` answers from the
caller's own guest-to-host map, raising `MicrosandboxPortNotMappedError` for
anything else rather than returning a URL that would dial the host.
There was a problem hiding this comment.
All reported issues were addressed across 20 files (changes from recent commits).
Requires human review: Auto-approval blocked because this review re-detected 3 unresolved issues already reported by Cubic.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Four came from a review pass over the branch, one from measuring the fix for
another. All five have a regression test where one can exist.
**microsandbox `exec` blocked for the process's entire lifetime.** The Docker
backend launches the journal wrapper with `docker exec --detach`; the copy here
dropped it, and the vendor has no equivalent — `execWith` resolves only once the
command has finished. Since the wrapper ends in `exec setsid --wait`, awaiting it
meant `exec()` returned only after the process it hands back a live handle to had
already exited, and `waitForExit({ timeout })` could never be reached to bound
anything. The launch is now backgrounded inside the guest instead; the wrapper
survives the launching shell because it runs under `setsid`. The shell semantics
are verified — the launcher returns in milliseconds, the child outlives it, and
nested quoting round-trips — but whether the runtime reaps the exec's tree is
not, like the rest of that backend.
**A stale just-bash timeout timer retroactively marked a finished command as
timed out.** The timer set `expired` unconditionally, and only `waitForExit`
stood it down, so a caller that read `status()` instead saw `timedOut: true` on a
command that had exited 0 well inside its budget. It is now armed after the
command exists and returns early if the command has already exited.
**The same timer kept the host process alive.** Measured after the fix above: a
30s budget nothing awaited held the process open for the full 30s, and an agent
turn's budget is hours. It is unreferenced now, asserted from a child process
because the claim is about the event loop rather than a value.
**The just-bash process registry was per-session-object, not per-provider.**
`SandboxProvider.session` is called per use by contract, so a `getProcess` after
a second `session()` over the same id answered `null` — contradicting the file's
own header. The registry moved to the provider, and is dropped with the handle on
`destroy`. Every existing test reused one session object, which is why nothing
caught it.
**The `microsandbox` peer range was empty**, meaning any version, while the type
copies target 0.6 and the devDependency pins `^0.6.15`.
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…iew found Applies the cubic review on #12. The findings that changed behaviour rather than wording: - local: a wrapper's pid is confirmed against its own argv before `alive` is believed. A wrapper SIGKILLed from outside writes no exit record, so `alive` is the only signal left — and a reused pid made `status()` report `running` forever and `kill()` aim a group signal at an unrelated tree. - local: `sandboxDirName` and microsandbox's `sandboxName` digest with SHA-256 rather than 32-bit FNV-1a. A digest collision means two sandboxes sharing one directory or one microVM, so one id's `destroy()` deletes the other's data. - local: `remove()` kills the process groups the journal still reports running before deleting the tree. Unlinking a directory does not stop a detached process writing into it. - local: `ready()` waits out an in-flight `remove()`, so an `exec()` racing a destroy cannot recreate the tree inside the `rm` and report itself ready over a directory that no longer exists. - local: `waitForExit` holds the escalation's exit record back until the group it describes is actually gone. That record is written before `kill -9`, by necessity, and returning there let a caller tear down over live descendants. - local: `getProcess` rejects an id that is not one this backend could have minted, so a path-shaped id reads nothing rather than reading outside the journal tree. - local: the watchdog's TERM trap kills `jobs -p` rather than `$nap`, closing the window between starting the sleep and assigning its pid. - just-bash: an aborted `logs()` returns at abort time. The vendor's `logs()` buffers until the command ends, so the old post-await check made an aborted read wait for exactly the command it gave up on. - just-bash: `loadJustBash` reports "not installed" only for a resolution failure naming the package; a module-evaluation error or a missing transitive dependency now propagates as itself. - just-bash: `session().destroy()` evicts its handle before awaiting teardown, so a concurrent `session(id)` cannot adopt a dying handle and have it deleted underneath it. - microsandbox: log offsets come from `fs().stat` gated on `fs().exists`, so a failed measurement is no longer reported as offset zero — which told a follow read to replay the whole log. - microsandbox: a failed read is only reported as `SandboxFileNotFoundError` once `exists` says the file is absent; anything else rethrows the vendor's own error. - microsandbox: the published-port map is copied rather than aliased. Two regression tests cover the reap on destroy and the rejected process id, and the cursor-resume test polls instead of racing a fixed 200ms nap against a 0.6s gap.
Resolves the two README conflicts: main renamed the npm scope to `@pleasedev` while this branch added three subpath rows to the same table, so the resolution keeps all six rows under the new scope. The rename is carried into the code this branch adds — the `local`, `just-bash` and `microsandbox` entry points and runtimes, and `docs/project-layout.md` — and into the two harness tests that assert the `pleasedev-` provider-id prefix. The `@pleaseai/...` names left in `contract/`, `docker/journal.ts` and `harness/index.ts` are the packages this code was vendored from, not this package, which is why main left them alone too.
…x on a boot Both failures came from the first CI run to exercise this branch on Linux. The watchdog's TERM trap was changed to `kill -9 $(jobs -p)` on the previous commit, to close the window between starting the nap and assigning its pid. That closed it on bash and opened a wider one everywhere else: a command substitution runs in a subshell, which does not inherit the job table, so under dash — Debian and Ubuntu's `/bin/sh` — it expands to nothing and every stand-down leaks its nap. CI caught it as a `sleep 987.654` left in the process table. The stand-down is now two traps: the first only records that a TERM arrived, because exiting is what strands the nap; the second replaces it once the pid is in hand, and the check between them catches a stand-down that landed while the first was installed. Every ordering is covered, and nothing outside POSIX is used. Verified against dash and bash in a Debian container, alongside the pre-change script for comparison. The microsandbox suite gated on `isMicrosandboxAvailable()`, which answers whether the runtime imports. On `darwin-x64` that is the whole answer, since there is no native addon; on the GitHub-hosted runner the addon loads and the guest then dies with SIGABRT before its agent relay comes up, for want of a hypervisor — so the suite ran and failed on every push. The gate now boots one throwaway sandbox and asks the host directly, which costs an extra boot where the answer is yes and is the only form of the question that does not guess at what the runtime needs underneath it.
Takes in `defineAgent` / `defineSandbox` (#13) and the dev-TUI boot chrome (#15). Both READMEs conflicted where main rewrote the Status section around the new public API while this branch added three backend rows and a note on where each backend's suite actually runs; the resolution keeps main's narrative and appends the coverage note, and folds the three new backend directories into main's deeper `src/agent` + `src/sandbox` layout tree. The three backends this branch adds ship no `SandboxBackendFactory` — the `docker({ image })` shape main introduced — because the adaptation is not mechanical for any of them: microsandbox takes ports as a guest→host map rather than a list, and just-bash publishes no ports at all, which `resolveSandbox` requires. Filed as a follow-up rather than decided in a merge.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The microsandbox backend was type-checked and never executed: it ships no native addon for darwin-x64 and needs a hypervisor the CI runner does not provide, so its integration suite skipped everywhere and six modules — session, process, process-state, process-logs, guest, files — had no line of coverage at all. Patch coverage came in at 65% against the repository's own 80% target, and the missing lines were almost entirely those six. The backend is written against structural copies of the vendor's types, which means it can be handed a different implementation of them. `test/sandbox/ microsandbox/fake-runtime.ts` is one: a `MicroSandbox` whose execs go to a real `execve` in a container and whose `fs()` reads and writes real files. It is a stand-in for the runtime, not a mock of the backend — nothing in it knows what the code under test intends to run, so a `sleep 30` really outlives the call that started it and a group kill really has to reach a grandchild. The assertions are the ones the real-microVM suite makes, kept identical so a change that breaks the backend breaks both. What it cannot stand in for — hypervisor isolation and the napi transport — is named rather than implied. Coverage on those six modules goes from 2-10% to 80-99%. Also covered, having been added by this PR and exercised by nothing: the local backend's SIGKILL escalation path (a command that ignores the timeout's SIGTERM), a log stream closing on `error` for a process that journalled no exit, and just-bash's aborted log read in both its forms. One fix rather than a test: microsandbox's provider had the same destroy race cubic found in just-bash's, evicting its cached handle in a `finally` after the teardown instead of before it, where a concurrent `session(id)` can adopt the dying handle and have it evicted underneath it.
…ery other boot gets The probe is a top-level `await`, which sits outside every per-test budget bun applies. A boot that fails is already handled; a boot that *hangs* — a stalled pull, or a hypervisor that deadlocks rather than aborting — would hang the whole file with no test to attribute it to. Racing it against `BOOT_TIMEOUT_MS` makes a hang the same answer as a rejection: this host cannot run the suite. The teardown is bounded for the same reason, and the timer is unreferenced so winning the race does not hold the process open for five minutes. Raised by cubic-dev-ai on #12.
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
… it would race Evicting the cached handle before awaiting the teardown fixed one race and opened another. A sandbox id resolves to a sandbox *name*, and acquiring a name adopts whatever the runtime's database already has under it — the property that makes an id resumable across host processes. So the fresh handle a concurrent `session(id)` builds is not independent of the one being torn down: it adopts the very VM that destroy is removing, and is left holding a machine about to be killed underneath it. `destroy()` now publishes its teardown per sandbox id, and a handle built while one is in flight has `ready`, `peek` and `remove` held behind it. The teardown's failure is swallowed at the gate and still raised to the caller that asked for the destroy: a caller merely queued behind it wants the timing, not the error, and a failed teardown that poisoned every later session would turn one bad destroy into a permanently unusable id. `local/root.ts` states the same rule for a directory rather than a VM. just-bash needs no equivalent — a new just-bash handle is an independent virtual filesystem, not a second claim on the same named machine. Also in the stand-in runtime: `execStreamWith` recorded a timeout and dropped it, while `execWith` enforced one. Nothing in the backend sets a deadline on a stream today, which is exactly what makes a silently ignored one a trap for the caller that does — a `tail -f` given a budget would run until the file it follows ends, which for a `follow` read is never. Raised by cubic-dev-ai on #12.
There was a problem hiding this comment.
All reported issues were addressed across 2 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…onstruction Capturing the in-flight teardown when the handle was built covered only the teardown that already existed. One starting afterwards is exactly as dangerous — a `ready()` on a handle built during a quiet moment could still adopt a VM a later destroy is removing — and a second destroy replacing the first while an acquire was suspended on it was covered only transitively. The gate now reads the teardown map when it is called, in a loop, which is the shape `ready()` in `local/root.ts` already had. Every handle is gated, since the gate costs a map lookup and no longer depends on what was in flight at build time. `remove` is deliberately left ungated: two teardowns for one name are removing the same machine, which is idempotent rather than a race, and gating it would make a destroy wait on the teardown it is itself about to publish. What this still does not cover is an acquire already past the gate when a destroy begins. Nothing short of a lock inside the handle would, and that is the ordinary use-after-destroy any backend has — the contract does not promise a session survives being torn down under it. Said in the source rather than left to be rediscovered. Also in the stand-in runtime: the streamed exec's budget timer is cleared when the child exits, the way `runExec` already clears its own. Unreferenced, it held nothing open, but it lingered for the rest of the budget with nothing left to do but abort a controller no one was listening to. Raised by cubic-dev-ai on #12.
Summary
Adds three backends for the sandbox contract —
@pleaseai/core/sandbox/local,@pleaseai/core/sandbox/just-bashand@pleaseai/core/sandbox/microsandbox— and movesSandboxFileNotFoundErroronto the contract so onecatchworks across all four.The three are siblings of
@pleaseai/core/sandbox/dockerand each is a different trade against it:localdrops the daemon and the isolation,just-bashdrops the host process and the real binaries,microsandboxkeeps the isolation and moves it from the namespace to the hypervisor. Each subpath is a separate entry point, so importing@pleaseai/corepulls none of them in.local— a host process, no daemonThe local backend's opposite trade with Docker is: no daemon, no image pull, no container — and no isolation. It exists for the cases where no daemon is reachable: a test suite that must run where Docker does not, a laptop working on the repository it is already inside, CI without a docker-in-docker rig.
The contract's durability requirement is what shapes it.
getProcess(id)andlogs({ replay: true })are read after a process exits, frequently by a host process that never started it, so commands are journalled to disk rather than held as child handles.Two pieces of the Docker wrapper could not come along, and both replacements were measured on darwin rather than assumed:
setsid. Replaced byBun.spawn({ detached: true }), which already makes the wrapper a process-group leader — verified by pgid separation and by a grandchild dying with the group.tailhas no--pid. Replaced by TypeScript file-offset polling that drains once more after the process stops.The wrapper script is a constant: the journal directory, the timeout and the argv travel beside it as positional parameters, so no quoting step sits between a caller's
SandboxCommandandexecve. The Docker backend needsshell-quote.tsbecause its wrapper travels as onesh -cstring; here it does not.Two policy decisions, both stated where they are made:
process.env(modelled on flue'sDEFAULT_LOCAL_ENV_ALLOWLIST), so the agent's bash tool does not inherit every credential the developer is carrying.IS_SANDBOXis deliberately not declared — the inverse of the Docker backend. There the claim is true; on the host it would be false, and the root check it defeats is the last thing between a bypassed permission prompt and the developer's own home directory.A sandbox id resolves to a backend-owned directory (
work/+journal/) under a caller-named root, which is what makesdestroy()safe to write at all: it only ever deletes a path the backend derived, never one a caller handed in.just-bash— a virtual shell, no host process at alljust-bashis a bash interpreter in TypeScript over an in-memory filesystem, so a command here is parsed and evaluated rather than executed. It is an optional peer dependency, imported dynamically, so its absence is an actionable install instruction rather than a module resolution failure from inside a workflow step.Four limits are structural, not gaps to be filled later. Each is pinned by a test, and each is a reason to choose a different backend rather than something this one can grow:
git --versionexits 127. The command set is the interpreter's own, so a turn that runs a package manager, a compiler orgitcannot run here at all.portEndpointraisesJustBashPortsUnavailableErrorrather than answeringhttp://127.0.0.1:<port>— a URL that would dial the host, which is both wrong and dangerous.logs({ follow: true })returns everything at the end rather than as it happens.wc -cinside the sandbox agreed.JustBashBinaryUnsupportedErrorrefuses the write instead of corrupting it.Two vendor behaviours needed accommodating:
runCommand({ env })is accepted and never applied — a command run withenv: { PROBE: 'x' }sees$PROBEempty. Per-exec env therefore goes through a constant wrapper script, with names, values and argv all passed as positional parameters. No interpolation, the same discipline as the local backend's journal wrapper.Module._resolveFilename, which fails under Bun and kills the first command rather than the constructor. It defaults to the runtime's support and stays caller-overridable.timedOutcomes from a timer this backend owns, because the interpreter reports every cancellation as exit code 124.microsandbox— isolation by hypervisorEach sandbox is a real kernel booted from an OCI image, so the isolation is a hypervisor boundary rather than a namespace one. Also an optional peer dependency, imported dynamically.
Verification status, stated plainly.
microsandboxships no native addon fordarwin-x64, the platform this was written on —import('microsandbox')throwsunsupported platform darwin-x64. The behavioural suite (27 tests) has therefore never been observed to pass: it gates onisMicrosandboxAvailable()and skips, the same shape the Docker suite uses for an unreachable daemon.What is verified everywhere:
test/sandbox/microsandbox/vendor-shape.test.tsasserts throughtscthat the vendor's own declarations still satisfy this package's structural copies of them. (The copies exist somicrosandboxstays out of the public.d.ts— a consumer who never installed the optional peer must still type-check.) That test earned its place immediately by rejecting two wrong models of the exec-options builder: a plain subset interface fails because a callback parameter is checked contravariantly, and a generic method fails because a concrete vendor signature cannot satisfy a universally quantified one. The builder had to become a type parameter on the interface. Drift detection was then demonstrated by adding a method to the copy and watchingtscreject it.test/sandbox/microsandbox/provider.test.ts(20 tests, runs everywhere) covers name-collision separation, the 128-byte name limit,IS_SANDBOX, and the port refusal.Three design decisions worth naming for reviewers:
../docker/journal.tsimported, not copied. That module contains no Docker — it is the POSIX shell every guest-side backend needs to make a process outlive the call that started it — and a second copy of 200 lines of signal-handling shell would drift. A shared home is the obvious refactor once a third guest backend appears.ExecHandleis deliberately not held by a process handle. It would answerwait()/kill()directly, but only for the host process that started the command — which is exactly the case the contract's durability requirement excludes.portEndpointanswers from the caller's own guest-to-host map and raisesMicrosandboxPortNotMappedErrorfor anything else, rather than returning a URL that would dial the host. The vendor's ownlogStreamwas considered and rejected: its entries are correlated by asessionIdthatExecHandlenever exposes.The contract change
SandboxFileNotFoundErrormoved onto the contract because both backends had declared a class of the same name — the one shape that satisfies every behavioural test and still fails the thing the class is for.instanceofanswered no across them, so a caller handed either backend could not write onecatch. Every subpath still exports the symbol, so no caller's import changes.harness/files.tsis untouched: it decides absence by re-checkingexists, not by matching an error type.Reviewer guidance
One entry point per backend:
packages/core/src/sandbox/local/journal.ts— the wrapper script and why it differs from Docker's;local/provider.tsfor the two policy decisions (env allowlist, noIS_SANDBOX).packages/core/src/sandbox/just-bash/provider.ts— the structural limits, stated where they are decided;just-bash/runtime.tsfor the two vendor accommodations.packages/core/src/sandbox/microsandbox/runtime.ts— the verification status in full, and the structural type copies thetsctest guards.Test coverage worth naming:
trap '' TERM.packages/core/test/sandbox/microsandbox/vendor-shape.test.ts— the only microsandbox coverage that runs on this platform; it type-checks rather than executes.packages/core/test/sandbox/contract/errors.test.tscompares the exports themselves rather than any behaviour — a backend that re-declared its own error class would pass every other test in the suite. It now covers four backends.Not in this PR
sandbox/docker/. Moving it to a neutral home is the refactor a third guest-side backend should trigger, not this PR.Related issue
None. This branch is not linked to an issue, and the one open issue (#8, demux backpressure) is unrelated.
Checklist
bun run test) — 315 pass, 27 skip (the microsandbox behavioural suite, which cannot load its addon ondarwin-x64), 0 failbun run lint,bun run type-check) —mise run cigreen (lint + type-check + test + build)IS_SANDBOXnotes for both new backends)BREAKING CHANGE:note is included — every subpath still exportsSandboxFileNotFoundError, so no import changesSummary by cubic
Adds three sibling sandbox backends to the Docker one and moves
SandboxFileNotFoundErroronto the contract so onecatchworks across all of them.localruns host processes with no isolation,just-bashis a virtual shell with no host process, andmicrosandboxis hypervisor-isolated but not yet verified against a live runtime. The merge with main brings the branch onto thedefineAgent/defineSandboxAPI and the@pleasedevscope; the new backends ship noSandboxBackendFactoryyet, which is filed as a follow-up.Backends
just-bashandmicrosandboxare optional peer dependencies imported dynamically; themicrosandboxsuite gates on a probe boot raced againstBOOT_TIMEOUT_MS, so a missing addon (darwin-x64), a failed boot, or a hang all skip.microsandboximports the Docker journal rather than copying it; all subpaths re-export the contract error, so no caller import changes.microsandbox's six previously-unexecuted modules are now exercised through a stand-in runtime backed by a real container, raising their coverage from 2-10% to 80-99%.Bug Fixes
microsandboxexecno longer blocks until the command finishes; the wrapper is launched backgrounded inside the guest.just-bashtimeout timer no longer marks finished commands as timed out or keeps the host process alive, and its process registry now lives on the provider.microsandboxpeer range is pinned to^0.6to match the type copies.sleep.getProcessrejects ids the backend never minted, and a wrapper's pid is checked against its own argv beforealiveis believed.microsandboxnarrows read errors toSandboxFileNotFoundErroronly when the file is absent;just-bashevicts a dying handle before teardown and reports "not installed" only for a resolution failure naming the package.microsandboxdestroy reads the in-flight teardown at call time and gates every handle on it, so a handle built during a quiet moment cannot adopt a VM a later destroy is removing; the stand-in runtime also enforces stream timeouts it previously dropped and clears their budget timer on child exit.Written for commit e638053. Summary will update on new commits.